Skip to Content

复习资料核心导航

核心考点串讲:

  1. 游戏Demo设定初衷 (贪吃蛇, 俄罗斯方块)
  2. Console平台知识回顾
  3. 游戏中的概率设置 (重点: Bag7规则)
  4. SFML核心知识 (交互, 纹理, 渲染)
  5. 【重点】Tetris功能函数细节拆解
  6. static关键字的用法
  7. 鼠标交互机制
  8. 碰撞检测机制 (AABB与穿模)
  9. 游戏舞台绘制原理
  10. 【重点】精灵表单与精灵动画
  11. 快速构建游戏的方法

考点一:了解教材游戏 Demo 的设定初衷

这部分是理解游戏设计递进关系的入门。

  • 贪吃蛇 (Snake):
    • 目的: 教学最基础的游戏循环、网格(Grid)概念、键盘输入、简单碰撞检测(撞墙、撞自己)和游戏状态管理(开始、游戏中、结束)。它是从静态程序到动态实时程序的“桥梁”。
  • 俄罗斯方块 (Tetris):
    • 目的: 引入更复杂的游戏逻辑。它在贪吃蛇的基础上,增加了物体旋转更复杂的碰撞逻辑(与已固定的方块)、行消除与数据移动游戏节奏控制(方块下落速度随等级提升)、以及更优的随机性(Bag7规则)。它极好地锻炼了数据结构(二维数组)与算法(碰撞、旋转、消行)的结合能力。
  • 扫雷 (Minesweeper): (虽然不考,但理解其目的有帮助)
    • 目的: 核心是鼠标交互状态管理(每个格子的 未打开/已打开/已插旗 状态)以及递归算法(点开一个空白格时,自动展开周围的空白区域)。

考点二:了解 Console 平台的交互、图形绘制

这是你从C语言到游戏开发的起点,理解其原理有助于理解SFML为何是巨大进步。

  • 图形绘制:
    • 原理: Console本质是字符界面,没有像素。所谓的“图形”是用字符(如 *, #, )模拟的。
    • 实现: 通过控制台API(如Windows的gotoxy())或ANSI转义序列,将光标移动到特定坐标 (x, y),然后用 printfcout 输出一个字符。
    • 双缓冲: 这是为了解决画面“闪烁”问题的核心技术。
      1. 在内存中创建一个二维字符数组 char screenBuffer[HEIGHT][WIDTH]
      2. 所有“绘制”操作都先更新这个内存中的数组。
      3. 当一帧的所有内容都“画”好后,一次性地将整个 screenBuffer 打印到屏幕上(通常会配合清屏操作)。这样人眼就看不到逐个字符绘制的过程,画面变得流畅。
  • 交互:
    • 原理: 标准的 cinscanf 是阻塞式的,会等待用户回车,不适用于实时游戏。
    • 实现: 使用非阻塞的输入函数,如 <conio.h> 中的 _kbhit() (检查是否有按键) 和 _getch() (直接获取按键,不回显)。
    • 游戏循环中的应用: 在每一帧循环中,调用 _kbhit() 检查输入,如果有,则用 _getch() 读取并处理。

考点三:了解游戏中的概率设置

  • 触发概率 (Trigger Probability):

    • 概念: 指某个事件发生的可能性。
    • 实现: 通常用 rand() 函数。例如,实现一个10%的概率触发事件:
      if ((rand() % 100) < 10) { // 触发事件 (100个数字中取到了0-9,共10个) }
  • 组合概率与伪随机 (重点: Tetris 的 Bag7 规则):

    • 问题: 纯随机 (rand() % 7) 可能导致玩家连续得到不想要的方块(如多个S和Z),或者长时间得不到关键的I长条。这会让游戏体验很差。
    • Bag7 规则 (七子一包): 是一种伪随机分布算法,能保证方块的“公平”出现。
    • 实现:
      1. 创建一个包含所有7种方块的“袋子”(如 std::vector<int> bag = {0, 1, 2, 3, 4, 5, 6};)。
      2. 洗牌: 使用 std::random_shuffle (旧) 或 std::shuffle (C++11及以后) 将袋子里的方块顺序打乱。
      3. 抽取: 每次需要新方块时,从袋子末尾取出一个并删除。
      4. 补充: 当袋子空了,重新将7种方块装入,再次洗牌。
    #include <vector> #include <algorithm> // for std::shuffle #include <random> // for std::mt19937 std::vector<int> pieceBag; std::mt19937 rng(std::random_device{}()); // 高质量随机数生成器 void refillBag() { pieceBag = {0, 1, 2, 3, 4, 5, 6}; // T, J, Z, O, S, L, I std::shuffle(pieceBag.begin(), pieceBag.end(), rng); } int getNextPiece() { if (pieceBag.empty()) { refillBag(); } int pieceType = pieceBag.back(); pieceBag.pop_back(); return pieceType; }

考点四:SFML 的基本知识

这是从Console迈向现代2D游戏开发的核心。

  • 背景与概念: SFML是一个简单快速的多媒体库,不是游戏引擎。它提供2D图形、音频、网络、窗口和输入的底层API,让我们能专注于游戏逻辑本身。
  • 交互 (Input):
    • 事件驱动: 通过 while(window.pollEvent(event)) 循环处理。适合一次性动作,如按键按下(sf::Event::KeyPressed)、鼠标点击(sf::Event::MouseButtonPressed)。
    • 实时查询: 在主循环中直接查询 sf::Keyboard::isKeyPressed(sf::Keyboard::Key)。适合持续性动作,如按住方向键移动。
  • 纹理 (Texture) 与精灵 (Sprite):
    • sf::Texture: 存储在显存中的图像资源。加载一次,可多次使用。生命周期必须覆盖所有使用它的Sprite
    • sf::Sprite: 一个可绘制实体,它引用一个sf::Texture来显示。可以对Sprite进行移动、旋转、缩放,而不会影响Texture本身。
  • 字体 (Font) 与文本 (Text):
    • 与Texture/Sprite关系类似。sf::Font字体资源sf::Text是使用该字体显示的可绘制实体
  • 渲染 (Rendering):
    • 核心流程 (The Game Loop):
      while (window.isOpen()) { // 1. 处理事件 // 2. 更新逻辑 (移动、碰撞、计分等) // 3. 渲染 window.clear(); // 清屏 window.draw(object); // 绘制所有对象 window.display(); // 显示到屏幕 }
    • window.clear(): 用背景色填充后台缓冲区。
    • window.draw(): 将对象绘制到后台缓冲区。
    • window.display(): 将后台缓冲区的内容与前台缓冲区交换,实现双缓冲,防止闪烁。

考点五:【重点】Tetris 的各功能函数细节

这是考试的核心,要求你对俄罗斯方块的实现有深入理解。

1. 核心数据结构

const int M = 20; // 舞台高度 const int N = 10; // 舞台宽度 // 游戏舞台 (Playfield),用二维数组表示。0表示空,非0表示方块颜色/类型 int field[M][N] = {0}; // 方块的形状定义 (4x4的二维数组) // 7种方块 x 4种旋转状态 int figures[7][4][4][4] = { /* ... 大量0和1定义 ... */ }; // 当前下落的方块 struct Point { int x, y; }; Point currentPiecePos; // 当前方块在舞台上的坐标 int currentPieceType; // 当前是哪种方块 (0-6) int currentRotation; // 当前旋转状态 (0-3)

2. 关键功能函数拆解

  • bool checkCollision() - 碰撞检测函数 (核心中的核心)

    • 目的: 判断当前方块在其当前位置是否与边界或已固定的方块发生重叠。
    • 逻辑:
      1. 遍历当前方块的4x4定义矩阵。
      2. 对于矩阵中为 1 的每一个小方块: a. 计算它在 field 舞台上的绝对坐标:int blockX = currentPiecePos.x + col; int blockY = currentPiecePos.y + row; b. 进行检查:
        • 是否超出左右边界? (blockX < 0 || blockX >= N)
        • 是否超出下边界? (blockY >= M)
        • 是否与 field 中已有的方块重叠? (field[blockY][blockX] != 0) c. 只要任意一个小方块满足上述任一条件,就立即返回 true (表示碰撞)。
    • 返回值: true 表示碰撞,false 表示安全。
    • 应用场景:
      • 移动前: currentPiecePos.x++ 后,调用 checkCollision()。如果为true,则撤销移动 currentPiecePos.x--
      • 旋转前: currentRotation++ 后,调用 checkCollision()。如果为true,则撤销旋转 currentRotation--。(高级玩法中这里会尝试“墙踢 Wall Kick”)
      • 判断落地: 当方块因重力下落一格后 checkCollision()true,说明它已经碰到底部或其他方块,需要被固定。
  • void placePiece() - 固定方块函数

    • 目的:checkCollision() 确认方块已落地,将其“印”在 field 数组上。
    • 逻辑:
      1. 再次遍历当前方块的4x4矩阵。
      2. 对于矩阵中为 1 的每一个小方块: a. 计算其在 field 上的绝对坐标。 b. 将 field 对应位置的值设为该方块的颜色/类型:field[blockY][blockX] = currentPieceType + 1; (加1是为了避免与0混淆)。
  • void clearLines() - 消行函数

    • 目的: 在固定一个新方块后,检查并消除所有满行。
    • 逻辑:
      1. field 的最底行 (M-1) 向上遍历到最顶行 (0)。
      2. 对每一行,检查是否已满(即该行所有 N 个格子都不为 0)。
      3. 如果第 i 行满了: a. 记录消行数(用于计分)。 b. 将 i 行之上的所有行整体向下移动一行
        • 一个高效的实现是从 i 行开始向上 for (int k = i; k > 0; k--),将 k-1 行的数据复制到 k 行:field[k] = field[k-1]
        • 最顶行 (field[0]) 则清空为全 0。 c. 注意: 因为下移了一行,当前行 i 需要重新检查一次,所以循环变量可以不变,或者使用 while 循环。
  • void rotatePiece() - 旋转函数

    • 目的: 改变 currentRotation 并检查合法性。
    • 逻辑:
      1. 保存原始旋转状态 int oldRotation = currentRotation;
      2. 更新旋转状态 currentRotation = (currentRotation + 1) % 4;
      3. 调用 checkCollision()
      4. 如果碰撞了 (checkCollision() 返回 true),则恢复原始旋转 currentRotation = oldRotation;

考点六:掌握 Static 的用法

static 关键字用于改变变量的存储周期和链接属性,或限定函数的作用域。

  • static 局部变量:

    • 位置: 函数内部。
    • 特点: 只在程序第一次执行到该声明时初始化一次。其生命周期是整个程序的运行时间,而不是函数调用期间。
    • 用途: 在函数调用之间保持状态。例如,一个只执行一次的初始化代码块。
      void someFunction() { static bool isInitialized = false; if (!isInitialized) { // 只会运行一次的初始化代码 isInitialized = true; } }
  • static 全局变量/函数:

    • 位置: 全局作用域(任何函数之外)。
    • 特点: 将变量或函数的链接属性external (外部) 变为 internal (内部)。这意味着该变量或函数只在其所在的源文件 (.cpp) 内可见,其他文件无法通过 extern 访问。
    • 用途: 避免多个文件间的命名冲突,实现数据或功能的封装。
  • static 类成员变量:

    • 特点: 该变量属于类本身,而不是类的某个特定对象。所有该类的对象共享同一个 static 成员变量。必须在类外进行初始化。
    • 用途:
      • 统计创建了多少个对象。
      • 管理共享资源,如一个 ResourceManager 类中的 static std::map<std::string, sf::Texture> textures;,确保纹理只加载一次。
      class Player { public: static int playerCount; Player() { playerCount++; } }; int Player::playerCount = 0; // 在类外初始化
  • static 类成员函数:

    • 特点: 该函数也属于类本身,不与任何对象关联,因此它没有 this 指针,不能访问非 static 成员变量。
    • 用途: 实现不需要对象实例就能调用的工具函数。比如 Math::clamp(value, min, max)
      class Config { public: static int getTileSize() { return 32; } }; // 调用方式: int size = Config::getTileSize();

考点七:掌握鼠标交互的机制

  • 事件处理:

    • pollEvent 循环中捕获鼠标事件:
      • sf::Event::MouseButtonPressed: 鼠标按键被按下。
      • sf::Event::MouseButtonReleased: 鼠标按键被释放。
      • sf::Event::MouseMoved: 鼠标移动。
  • 获取信息:

    • event.mouseButton.button: 判断是哪个键(sf::Mouse::Left, sf::Mouse::Right)。
    • event.mouseButton.x, event.mouseButton.y: 获取点击事件发生时的窗口坐标
    • sf::Mouse::getPosition(window): 实时获取鼠标相对于窗口的当前坐标。
  • 坐标转换 (重要):

    • 如果你的游戏使用了 sf::View (摄像机),那么鼠标的窗口坐标不等于游戏世界坐标。
    • 必须使用 window.mapPixelToCoords(mousePosition) 将窗口像素坐标转换为游戏世界坐标。
  • 点击检测:

    • 最常用的方法是检测鼠标坐标是否在某个对象的包围盒 (Bounding Box) 内。
    • sf::Sprite, sf::Text, sf::Shape 都有 getGlobalBounds() 方法,返回一个 sf::FloatRect
    • sf::FloatRect 有一个 contains(x, y) 方法。
    if (event.type == sf::Event::MouseButtonPressed) { if (event.mouseButton.button == sf::Mouse::Left) { // 获取鼠标在窗口中的像素位置 sf::Vector2i pixelPos = sf::Mouse::getPosition(window); // 转换为游戏世界坐标 sf::Vector2f worldPos = window.mapPixelToCoords(pixelPos); // 检查是否点中了某个精灵 if (mySprite.getGlobalBounds().contains(worldPos)) { // 点击成功! } } }

考点八:掌握碰撞检测机制的底层逻辑(穿模)

  • AABB (Axis-Aligned Bounding Box) 轴对齐包围盒:

    • 这是2D游戏中最简单、最常用的碰撞检测算法。一个矩形的 (x, y, width, height) 定义了它的范围。
    • SFML实现: sprite.getGlobalBounds() 返回的 sf::FloatRect 就是一个AABB。rect1.intersects(rect2) 直接完成了检测。
    • 底层逻辑: 两个AABB(r1, r2)相交的条件是:
      • r1.x < r2.x + r2.width (r1的左边 在 r2的右边 的左侧)
      • r1.x + r1.width > r2.x (r1的右边 在 r2的左边 的右侧)
      • r1.y < r2.y + r2.height (r1的上边 在 r2的下边 的上侧)
      • r1.y + r1.height > r2.y (r1的下边 在 r2的上边 的下侧)
      • 这四个条件必须同时满足。
  • 穿模 (Tunneling):

    • 现象: 当一个物体在一帧内的移动速度过快,其位移大于另一个物体的厚度时,它可能在上一帧还在物体前,下一帧就“跳”到了物体后,导致intersects检测在两帧都为false,从而“穿过”了障碍物。
    • 原因: 游戏是离散的,我们只在每一帧的特定时间点检查位置,而不是连续检查。
    • 解决方案 (从易到难):
      1. 限制速度: 最简单的办法,确保物体的最大位移小于潜在碰撞物的最小尺寸。
      2. 子步进 (Sub-stepping): 在一帧内,将物体的移动分成几个小步骤。每移动一小步就做一次碰撞检测。这是性能和效果的良好折衷。
      3. 连续碰撞检测 (CCD): 预测物体在下一帧的运动轨迹(通常是条线段),检查这个轨迹是否会与其他物体相交。这是最精确但最复杂的方法。

考点九:掌握游戏舞台绘制的原理

  • 绘制顺序 (Z-ordering):

    • SFML的绘制遵循“画家算法”,后画的会覆盖先画的。
    • 正确的绘制顺序至关重要:
      1. window.draw(backgroundSprite); // 先画背景
      2. window.draw(environmentObjects); // 再画场景物体
      3. window.draw(playerSprite); // 然后画玩家和敌人
      4. window.draw(foregroundEffects); // 再画前景(如粒子效果)
      5. window.draw(uiText); // 最后画UI界面
  • 位置摆放与偏移 (Coordinate Systems):

    • 游戏逻辑坐标 (Grid/World Coordinates): 这是你在游戏逻辑中使用的坐标。例如,Tetris的 field[y][x],这里的 (x, y) 就是逻辑坐标。
    • 屏幕渲染坐标 (Screen Coordinates): 这是物体在窗口上显示的实际像素坐标。
    • 转换: 绘制时,需要将逻辑坐标转换为屏幕坐标。
      • 公式: screenX = stageOffsetX + logicX * tileSize;
      • screenY = stageOffsetY + logicY * tileSize;
    • stageOffsetX/Y: 游戏舞台(如Tetris的field)在整个窗口中的左上角偏移量。
    • tileSize: 每个逻辑单元(格子)对应的像素大小。

    Tetris绘制示例:

    int tileSize = 32; int stageOffsetX = 100; int stageOffsetY = 50; // 绘制舞台上已固定的方块 for (int y = 0; y < M; y++) { for (int x = 0; x < N; x++) { if (field[y][x] != 0) { sf::RectangleShape block(sf::Vector2f(tileSize, tileSize)); block.setFillColor(getColorForType(field[y][x])); // 关键的坐标转换 block.setPosition(stageOffsetX + x * tileSize, stageOffsetY + y * tileSize); window.draw(block); } } }

考点十:【重点】精灵表单 (Sprite Sheet) 与 精灵动画 (Sprite Animation)

这个考点是图形效率和表现力的关键。

1. 精灵表单 (Sprite Sheet / Texture Atlas)

  • 是什么: 一个包含多个独立图像(如图块、动画帧、UI元素)的单张大图

  • 为什么用:

    • 性能: GPU切换当前使用的纹理是一项耗时操作。将许多小图合并成一张大图,可以极大减少GPU的纹理切换次数(也叫 Draw Call 的合并),从而显著提升渲染性能。
    • 管理: 管理一个大文件比管理成百上千个小文件要容易得多。
  • 如何用:

    1. 加载整张大图到一个 sf::Texture
      sf::Texture spriteSheet; spriteSheet.loadFromFile("character_walk.png");
    2. 创建一个 sf::Sprite,并设置其纹理为这张大图。
      sf::Sprite playerSprite; playerSprite.setTexture(spriteSheet);
    3. 使用 sprite.setTextureRect(sf: :IntRect(left, top, width, height))指定要显示大图的哪个子区域sf::IntRect 的四个参数定义了一个矩形。

    示例: 假设一张 128x32 的图上有4帧 32x32 的动画。

    • 显示第1帧: playerSprite.setTextureRect(sf: :IntRect(0, 0, 32, 32));
    • 显示第2帧: playerSprite.setTextureRect(sf: :IntRect(32, 0, 32, 32));
    • 显示第3帧: playerSprite.setTextureRect(sf: :IntRect(64, 0, 32, 32));

2. 精灵动画 (Sprite Animation)

  • 是什么: 通过快速、连续地切换精灵显示的 TextureRect,给人眼造成物体在动的错觉。
  • 如何实现:
    1. 数据准备:

      • 定义动画的所有帧在精灵表单上的位置(一系列的 sf::IntRect)。
      • 一个变量记录当前播放到第几帧 int currentFrame
      • 一个计时器 sf::Clock 来控制播放速度。
      • 一个变量定义每帧的持续时间 float frameDuration
    2. 在游戏循环中更新:

      class Animation { public: std::vector<sf::IntRect> frames; float frameDuration = 0.1f; // 每帧持续0.1秒 int currentFrame = 0; sf::Clock clock; void update() { if (clock.getElapsedTime().asSeconds() > frameDuration) { // 时间到了,切换到下一帧 currentFrame = (currentFrame + 1) % frames.size(); // 循环播放 clock.restart(); } } sf::IntRect getCurrentFrameRect() { return frames[currentFrame]; } }; // 在你的游戏类中... Animation playerWalkAnim; // ... 在初始化时,填充 playerWalkAnim.frames ... // playerWalkAnim.frames.push_back(sf::IntRect(0, 0, 32, 32)); // playerWalkAnim.frames.push_back(sf::IntRect(32, 0, 32, 32)); // ... // 在主循环的更新部分 playerWalkAnim.update(); // 在主循环的渲染部分 playerSprite.setTextureRect(playerWalkAnim.getCurrentFrameRect()); window.draw(playerSprite);

考点十一:掌握快速构建游戏的方法

这部分是经验和编程思想的总结。

  1. 迭代开发 (Iterative Development):

    • 不要试图一次性写完所有功能。
    • 从最小可行产品 (MVP) 开始:
      1. 先让一个窗口能打开和关闭。
      2. 在窗口里画一个方块。
      3. 让方块能响应键盘输入移动。
      4. 添加另一个方块,实现碰撞检测。
      5. …逐步增加功能。
  2. 代码模块化 (Modular Code):

    • 封装: 将相关的数据和功能封装到类中。例如,创建一个 Player 类,一个 Enemy 类,一个 GameStateManager 类。这使得代码更易于管理和复用。
    • 分离:游戏逻辑渲染代码分开。一个对象的 update() 方法负责处理其逻辑(移动、AI),而 draw() 方法只负责将其绘制到屏幕上。
  3. 使用占位符 (Placeholders):

    • 在没有最终美术资源时,不要干等。使用 sf::RectangleShape, sf::CircleShape 等基本形状作为临时替代品(“placeholder art”)。这能让你专注于核心玩法的开发。
  4. 资源管理 (Resource Management):

    • 不要在主循环中加载资源! loadFromFile 是耗时操作。
    • 创建一个 ResourceManager 类(通常使用 static 成员),在游戏开始时一次性加载所有需要的纹理、字体和声音。游戏过程中,任何需要资源的地方都向这个管理器请求,而不是自己去加载。

补充

俄罗斯方块中的踢墙机制

好的,我们来详细讲解俄罗斯方块中的踢墙 (Wall Kick) 机制。这是一个从经典版到现代版俄罗斯方块的重要演进,极大地改善了游戏的手感和流畅度。

1. 为什么需要踢墙?

想象一个经典(没有踢墙)的俄罗斯方块。当一个方块(比如 T 形块)紧贴着墙壁或已经固定的方块时,如果你尝试旋转它,会发生什么?

  1. 执行旋转: 方块的形状改变,某些小方格会移动到新的相对位置。
  2. 碰撞检测: 新的位置很可能会与墙壁或旁边的方块重叠。
  3. 旋转失败: 由于发生了碰撞,系统会判定这次旋转无效,方块会立刻恢复到旋转前的状态。

结果就是: 玩家会感觉“卡住了”,明明看起来有空间,但方块就是不让你转。这非常影响游戏体验,尤其是在高速下落时,一次失败的旋转可能直接导致游戏结束。

踢墙机制就是为了解决这个问题而生的。

2. 踢墙的核心思想

踢墙的核心思想是:当一次常规旋转因为碰撞而失败时,不要立刻放弃。尝试将方块向左或向右平移一到两个格子,再次检查是否能成功旋转。如果平移后能找到一个不碰撞的位置,那么就将方块“踢”到那个位置并完成旋转。

这就像你在一个狭窄的走廊里搬一个沙发,直接转不过去,但你把沙发往旁边挪一点点,可能就正好能转过来了。这个“挪一点点”的动作,就是“踢墙”。

3. SRS (Super Rotation System) - 现代俄罗斯方块的标准

目前绝大多数官方和主流的俄罗斯方块游戏都遵循一个名为 SRS (Super Rotation System) 的标准。SRS 精确地定义了每种方块在每次旋转时应该尝试哪些“踢墙”位置,以及尝试的顺序。

3.1 关键要素:旋转中心 (Pivot)

首先,要理解 SRS,必须知道每个方块的旋转中心点(Pivot)。除了 O 形块(不旋转)和 I 形块(旋转中心比较特殊)外,其他方块(T, J, L, S, Z)的旋转中心通常都在其 3x3 的包围盒内。

当执行旋转时,是围绕这个中心点来移动其他小方格的。

3.2 踢墙测试表 (Wall Kick Tables)

SRS 的核心是一系列偏移测试表 (Offset Test Tables)。当一次旋转失败时,系统会按照预定义的顺序,尝试将方块应用这些偏移量。

  • 偏移量通常表示为 (Δx, Δy)
  • 不同的方块 (IO vs 其他) 和不同的旋转方向 (顺时针 vs 逆时针) 会使用不同的测试表。

我们来看一个最常见的 J, L, S, T, Z 方块的踢墙数据表。表格中的数字代表从一个旋转状态到另一个旋转状态时,需要进行的5次测试(包括一次不偏移的初始测试)。

旋转状态定义:

  • 0: 初始状态
  • R: 顺时针旋转90度 (Right)
  • L: 逆时针旋转90度 (Left)
  • 2: 旋转180度
旋转测试 1 (初始)测试 2测试 3测试 4测试 5
0 -> R( 0, 0)(-1, 0)(-1, +1)( 0, -2)(-1, -2)
R -> 0( 0, 0)(+1, 0)(+1, -1)( 0, +2)(+1, +2)
R -> 2( 0, 0)(+1, 0)(+1, -1)( 0, +2)(+1, +2)
2 -> R( 0, 0)(-1, 0)(-1, +1)( 0, -2)(-1, -2)
2 -> L( 0, 0)(+1, 0)(+1, +1)( 0, -2)(+1, -2)
L -> 2( 0, 0)(-1, 0)(-1, -1)( 0, +2)(-1, +2)
L -> 0( 0, 0)(-1, 0)(-1, -1)( 0, +2)(-1, +2)
0 -> L( 0, 0)(+1, 0)(+1, +1)( 0, -2)(+1, -2)

注意:

  • 坐标系通常是 (x, y),其中 x 向右为正,y 向下为正。但有些实现中 y 向上为正,这会影响 +- 号。上表假设 y 轴向下为正。
  • I 形块有自己的一套更大幅度的踢墙表,因为它更长。
  • O 形块没有踢墙数据,因为它不旋转。

4. 踢墙的实现逻辑

现在,我们把这个机制整合到你的 rotatePiece() 函数中。

旧的 rotatePiece() (无踢墙):

void rotatePiece() { int oldRotation = currentRotation; currentRotation = (currentRotation + 1) % 4; // 尝试旋转 if (checkCollision()) { // 如果碰撞 currentRotation = oldRotation; // 撤销旋转 } }

新的 rotatePiece() (带踢墙):

// 假设你已经定义好了上面的踢墙数据表 // srs_kick_data[piece_type][rotation_transition][test_index] -> {dx, dy} void rotatePiece(bool clockwise) { int oldRotation = currentRotation; int newRotation; if (clockwise) { newRotation = (currentRotation + 1) % 4; } else { newRotation = (currentRotation + 3) % 4; // +3 等价于 -1 } // 1. 获取本次旋转需要使用的测试数据表 // (这需要一个函数或查找表来确定,比如从 0->R, R->2 等) const auto& kickTests = getKickDataForTransition(currentPieceType, oldRotation, newRotation); // 2. 依次尝试每个踢墙测试 for (int i = 0; i < 5; ++i) { // 获取第 i 个测试的偏移量 Point offset = kickTests[i]; // 3. 临时应用偏移量 Point originalPos = currentPiecePos; currentPiecePos.x += offset.x; currentPiecePos.y += offset.y; // 4. 在新位置、新旋转状态下进行碰撞检测 currentRotation = newRotation; // 临时应用旋转 bool success = !checkCollision(); // 检查是否成功 (不碰撞) // 恢复旋转和位置,以便下一次循环或最终确认 currentRotation = oldRotation; currentPiecePos = originalPos; // 5. 如果测试成功 if (success) { // 正式应用旋转和踢墙后的位置 currentRotation = newRotation; currentPiecePos.x += offset.x; currentPiecePos.y += offset.y; return; // 成功,函数结束 } } // 如果所有5次测试都失败了,说明真的转不了,什么也不做。 }

代码逻辑分解:

  1. 确定目标状态: 计算出旋转后的 newRotation
  2. 获取踢墙数据: 根据方块类型、原始旋转状态和目标旋转状态,从预定义的数据表中找出对应的5组偏移测试 (Δx, Δy)
  3. 循环测试:
    • 测试0 ((0, 0)): 这是常规旋转,不进行任何平移。如果这次就成功了,函数直接返回,手感和经典版一样。
    • 测试1-4: 如果测试0失败,就进入真正的“踢墙”环节。
    • 在每次测试中,临时将方块移动到 当前位置 + 偏移量,并临时改变其旋转状态。
    • 调用 checkCollision()
    • 如果不碰撞 (checkCollision() 返回 false),说明找到了一个可行的位置!
      • 正式将方块的位置和旋转更新到这个成功的状态。
      • 立即 return,不再尝试后续的测试。
  4. 全部失败: 如果循环结束,所有5次测试都失败了,那么这次旋转就是无效的,函数结束,方块保持原状。

5. T-Spin (T旋) - 踢墙机制的妙用

踢墙机制不仅提升了基本的游戏手感,还催生了一种高级技巧——T-Spin

  • 定义: T-Spin 指的是利用踢墙,将 T 形块旋转塞入一个看似无法直接进入的 “T” 形凹槽中。
  • 价值: 在现代俄罗斯方块规则中,完成 T-Spin 并消行会获得极高的分数奖励,甚至比消除四行 (Tetris) 的奖励还要高。这鼓励玩家去创造和利用这些复杂的形状。

上图中,T 形块无法直接下落到那个空位里。但玩家可以把它移动到空位的正上方,然后执行一次旋转。由于直接旋转会和上方的方块碰撞,踢墙机制启动,将 T 块向下“踢”了一格,正好嵌入凹槽。

Last updated on